Next.js 페이지가 static이 되기 위한 조건

일반
2026년 7월 4일
/library-framework/nextjs/Next.js 페이지가 static이 되기 위한 조건
목차
1. 왜 이걸 신경써야 하나
2. 기본 원칙: Next.js는 static이 기본이다
2.1. 빌드 결과로 확인하기
3. static을 깨뜨리는 것들
3.1. 1. Dynamic API (요청 시점 정보)
3.2. 2. 캐시되지 않은 fetch
3.3. 3. Route Segment Config
4. static을 지키거나 강제하는 법
4.1. 그냥 dynamic API를 안 쓴다
4.2. force-static — 억지로라도 static으로
4.3. error — static을 어기면 빌드를 깨뜨린다
4.4. generateStaticParams — 동적 세그먼트를 static으로
5. 정리: 판단 순서
6. (참고) Next.js 16의 Cache Components

왜 이걸 신경써야 하나

같은 코드인데 어떤 페이지는 빌드 타임에 HTML로 미리 구워지고(static), 어떤 페이지는 요청이 올 때마다 서버에서 새로 그려진다(dynamic). 이 차이가 어디서 갈리는지 알아보자.

static으로 구워진 페이지는 CDN에 올려두고 그냥 뿌려주면 되니까 빠르고 서버 부하도 없다. 반대로 dynamic 페이지는 요청마다 서버가 렌더링을 돌려야 한다. 그래서 "이 페이지가 왜 static이 안 되지?" 를 디버깅할 일이 자주 생기는데, 그때 판단 기준을 알고 있어야 한다.

이 글은 App Router 기준이며, Next.js 16 문서를 기준으로 작성했다. (cacheComponents를 켜지 않은 기본 모델 기준이다. 이건 맨 아래에서 따로 언급한다.)

기본 원칙: Next.js는 static이 기본이다

App Router에서 페이지는 아무것도 특별한 걸 안 쓰면 static이다. 빌드할 때 Next.js가 최대한 미리 렌더링해서 HTML로 구워둔다.

// 이 페이지는 static이다. 요청 시점 정보가 하나도 없다.
export default function Page() {
  return <h1>Hello</h1>;
}

export const dynamic = 'auto' 라는 게 기본값인데, 이건 "가능한 한 최대로 static으로 구우려고 시도한다"는 뜻이다. 그러다가 요청 시점에만 알 수 있는 정보(쿠키, 헤더, URL 쿼리 등)를 건드리는 순간 "아 이건 미리 못 굽겠네" 하고 그 페이지를 dynamic으로 떨어뜨린다.

빌드 결과로 확인하기

next build를 돌리면 각 라우트가 어떻게 렌더링되는지 심볼로 찍어준다. 아래는 이 글의 실습 데모(demo/apps/nextjs-static-conditions)를 빌드한 실제 출력이다. 라우트마다 조건을 하나씩만 걸어둬서 심볼과 조건이 1:1로 대응된다. (오른쪽 는 설명용 주석, 핵심 라우트만 발췌)

Route (app)            Revalidate  Expire
┌ ○ /
├ ○ /a-plain                            ← 동적 API 없음
├ ƒ /b-cookies                          ← cookies()
├ ƒ /c-headers                          ← headers()
├ ƒ /d-searchparams                     ← searchParams
├ ƒ /e-fetch-uncached                   ← no-store fetch
├ ○ /f-fetch-cached                     ← 로컬 데이터 직접 호출
├ ○ /g-isr                     1m      1y  ← revalidate=60
├ ○ /h-force-static                     ← force-static + cookies()
├ ƒ /i-force-dynamic                    ← force-dynamic
├ ● /j-gen/[slug]                       ← generateStaticParams
│ ├ /j-gen/1
│ ├ /j-gen/2
│ └ /j-gen/3
└ ƒ /k-nogen/[slug]                     ← gen 없이 params

(Static)   prerendered as static content
(SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
  • static — 빌드 타임에 통째로 HTML로 구워짐
  • SSG — 동적 세그먼트([slug])를 generateStaticParams로 미리 만들어 구워둔 것. 결국 static이다
  • ƒ dynamic — 요청마다 서버 렌더링

즉 페이지가 static이 되었는지 아닌지는 이 빌드 출력만 봐도 바로 확인된다. 아래 이어지는 조건들을 이 데모에서 직접 켜고 끄며 심볼이 바뀌는 걸 확인할 수 있다.

Next.jsstatic/dynamic 판정 실습

⚡ 서버 런타임이 필요해 라이브 임베드 대신 코드로 제공합니다.

코드 보기 ↗
재현
pnpm install && cd apps/nextjs-static-conditions && pnpm build

static을 깨뜨리는 것들

그럼 무엇이 페이지를 ƒ dynamic으로 떨어뜨리냐. 크게 세 부류다. 이 중 하나라도 페이지(또는 그 안의 컴포넌트)에서 쓰이면 그 라우트 전체가 dynamic이 된다.

1. Dynamic API (요청 시점 정보)

요청이 실제로 들어와야만 값이 정해지는 API들이다. 빌드 타임엔 "요청"이라는 게 없으니 이걸 쓰는 순간 미리 구울 수가 없다.

import { cookies, headers, draftMode } from "next/headers";
import { connection } from "next/server";

export default async function Page({ searchParams }) {
  const cookieStore = await cookies();   // 쿠키
  const headersList = await headers();   // 요청 헤더
  // draftMode(), connection() 도 마찬가지
  // searchParams (URL 쿼리) 를 읽는 것도 dynamic 전환
}

목록으로 정리하면:

  • cookies()
  • headers()
  • draftMode()
  • connection() (요청이 들어올 때까지 렌더링을 미루는 함수)
  • searchParams prop (URL 쿼리스트링)
  • params prop — 단, generateStaticParams로 샘플 경로를 만들어주면 static으로 구울 수 있다

원리를 조금 더 파보면, 빌드 중(prerender 중)에 cookies() 같은 걸 호출하면 Next.js 내부에서 DynamicServerError를 던지면서 revalidate = 0으로 세팅해버린다. 이 에러가 "정적 생성 중단(bail out)" 신호가 되어서 해당 라우트를 dynamic으로 돌리는 것이다.

2. 캐시되지 않은 fetch

Next.js 15부터 fetch는 기본적으로 캐시되지 않는다(no-store). 예전엔 기본 캐시였는데 바뀌었다. 이게 은근 많이 걸린다.

export default async function Page() {
  // Next 15+ 에서는 이 fetch가 기본 no-store → 매 요청 재실행 → 페이지 dynamic
  const res = await fetch("https://api.example.com/data");
  const data = await res.json();
  return <div>{data.title}</div>;
}

static으로 굽고 싶으면 명시적으로 캐시를 켜줘야 한다.

// 캐시 켜기 (한 번 굽고 재사용)
const res = await fetch("https://...", { cache: "force-cache" });

// 혹은 주기적으로 갱신 (ISR)
const res = await fetch("https://...", { next: { revalidate: 3600 } });

fetch를 안 쓰는 DB 직접 호출 등은 unstable_noStore()(또는 connection())를 호출하면 같은 방식으로 dynamic으로 빠진다.

3. Route Segment Config

페이지/레이아웃에서 설정값을 export해서 강제로 dynamic으로 만들 수도 있다.

export const dynamic = "force-dynamic";   // 무조건 dynamic
export const revalidate = 0;              // 항상 dynamic 렌더링
export const fetchCache = "force-no-store"; // 모든 fetch를 no-store로

dynamic = 'force-dynamic'은 사실상 그 페이지의 모든 fetch{ cache: 'no-store' }로 만드는 것과 같다.

static을 지키거나 강제하는 법

반대로 static을 유지하거나 강제하는 방법이다.

그냥 dynamic API를 안 쓴다

가장 기본. 위의 세 부류를 안 쓰면 페이지는 static이다. fetch를 쓴다면 force-cacherevalidate를 붙여준다.

force-static — 억지로라도 static으로

dynamic = 'force-static'을 주면 cookies(), headers(), useSearchParams()에러 대신 빈 값을 반환하도록 만들어서, 강제로 static으로 굽는다.

export const dynamic = "force-static";

// 이 안에서 cookies()를 써도 에러 안 나고 그냥 빈 값이 나옴 → static 유지

요청별 정보를 못 읽게 되는 대신 static을 보장받는 셈이다.

error — static을 어기면 빌드를 깨뜨린다

dynamic = 'error'는 "이 페이지는 반드시 static이어야 한다"고 못을 박는 옵션이다. 만약 안에서 dynamic API나 uncached fetch를 쓰면 빌드 타임에 에러를 낸다. 실수로 static이 깨지는 걸 방지하고 싶을 때 쓴다.

generateStaticParams — 동적 세그먼트를 static으로

[slug] 같은 동적 경로도 어떤 값들이 올지 빌드 타임에 알려주면 static으로 구울 수 있다.

export async function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "world" }];
}

export const dynamicParams = false; // 위에서 만든 것 외의 경로는 404

이 방식은 [[Next.js의 SSG를 활용한 마크다운 페이지 렌더링 자동화]]에서 마크다운 경로들을 static 페이지로 굽는 데 실제로 써먹었다. dynamicParams = false를 주면 명시한 경로 외에는 전부 404로 막아서, static으로 구운 경로만 허용하게 된다.

정리: 판단 순서

결국 "이 페이지가 static이 되냐"는 이렇게 따지면 된다.

  1. cookies() / headers() / draftMode() / connection() / searchParams를 쓰나? → 쓰면 dynamic
  2. params를 쓰는데 generateStaticParams가 없나? → 없으면 dynamic
  3. 캐시 안 한 fetch(기본 no-store)나 unstable_noStore()가 있나? → 있으면 dynamic
  4. dynamic = 'force-dynamic' / revalidate = 0 을 줬나? → 줬으면 dynamic
  5. 위에 다 해당 안 되면 → static

헷갈리면 그냥 next build 돌려서 심볼( / / ƒ)을 보는 게 제일 확실하다.

(참고) Next.js 16의 Cache Components

Next.js 16에는 cacheComponents: true 플래그로 켜는 새 모델이 있다. 이걸 켜면 위에서 말한 "페이지 하나가 통째로 static이냐 dynamic이냐"라는 이분법이 사라지고, 한 페이지 안에서 static 부분과 dynamic 부분이 공존한다(부분 프리렌더링, PPR). 페이지는 항상 static shell을 갖고, dynamic한 조각만 <Suspense>로 감싸 요청 시점에 스트리밍하는 방식이다.

다만 이건 옵트인 기능이고 이 글의 기본 모델과는 판단 규칙 자체가 다르므로, 별도 문서에서 따로 다룬다.